埋点还可以这么做?这也太简单了
作者:慢功夫
https://juejin.cn/post/7238431954126929981
前言
在项目开发中通常会有埋点的需求,然而当项目过于庞大,给每个函数添加埋点函数是不现实的,这是其一。其二,埋点和业务逻辑没有关系,混入代码中会导致维护混乱🤪。
基于此,我们可以将埋点的任务用工具来做,而不是手动。这个工具就是babel
下面我们来看一个小Demo,看看babel埋点是如何实现的
这篇文章适用于了解babel插件开发基础的童鞋,同时想要了解用bable埋点的基本思路的童鞋
一个埋点的Demo
安装依赖
新建一个文件夹babel-tracker, 然后在这个文件夹内按照必要的依赖
mrdir babel-tracker
cd ./babel-tracker
npm init -y
npm i -D @babel/core @babel/helper-plugin-utils
添加测试代码
创建一个测试代码src/sourceCode.js,这个代码是用来测试添加埋点函数
//sourceCode.js
import "./index.css";
//##箭头函数
const test1 = () => {};
代码中有函数表达式,箭头函数,函数声明,类方法四种函数,待会就要在这四种方法里面分别插入埋点函数,就像下面这样
import _tracker from "tracker";
import "./index.css";
//##箭头函数
const test1 = () => {
_tracker();
};
编写入口文件
然后新建一个文件src/index.js
const { transformFileSync } = require("@babel/core");
const path = require("path");
const pathFile = path.resolve(__dirname, "./sourceCode.js");
//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [
//plugins
],
});
console.log(code);
这个文件做了三件事:
获取测试代码的文件路径 将测试代码用tracker 插件处理 将处理后的code打印出来
其中的第二步,是先将代码转成AST语法树,然后用插件对AST对象树做一系列的处理,最后将处理好的AST转回js代码。
我们所有的重点就是在用插件处理AST上面。
下面来创建一个插件src/babel-plugin-tracker-2.js
编写插件
基本思路是,先识别出这是一个函数,然后将在函数体内部添加一个表达式_tracker()
// 导出一个 Babel 插件的函数。它接受两个参数:
// `api` 是一个 Babel 插件 API 对象,提供了一些可以在插件中使用的方法。
// `options` 是用户在 Babel 配置文件中给该插件指定的选项。
module.exports = (api, options) => {
// 返回一个插件对象。
return {
// `visitor` 对象定义了我们要访问的 AST 节点类型以及对应的处理方法。
visitor: {
// 对于 `ArrowFunctionExpression` 类型的节点(箭头函数表达式):
ArrowFunctionExpression: {
// 当我们进入一个节点时:
enter: (path, state) => {
// `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。
// 获取箭头函数的函数体的路径。
const bodyPath = path.get("body");
// 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
// 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
const ast = api.template.statement('_tracker()')();
// 将新生成的 `_tracker()` 调用语句插入到箭头函数的函数体的开头。
bodyPath.node.body.unshift(ast);
},
},
},
};
};
我们将插件导入src/index.js文件中
//index.js
const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker"); //update
const pathFile = path.resolve(__dirname, "./sourceCode.js");
//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [[tracker]], //update
});
console.log(code);
运行Demo
好了,将写好的插件导入之后,就可以运行代码看看效果了
node ./src/index.js
运行成功
可以看到埋点的函数已经被放进去了。可以有个小问题,这个文件运行起来可能会报错,因为没有_tracker函数的import,需要先import才不会报错。
接下来我们来处理这个问题
处理_tracker的import
一般在bable中处理important是在Program的AST节点中处理的,所以需要在插件中处理Program节点.。
基本思路是,判断文件中是否有_tracker的import,如果没有,就添加一个导入
// 导入 `@babel/helper-module-imports` 包的 `addDefault` 函数
// 它可以向程序中添加默认导入
const { addDefault } = require("@babel/helper-module-imports");
// 导出一个 Babel 插件的函数。
module.exports = (api, options) => {
return {
visitor: {
ArrowFunctionExpression: {
enter: (path, state) => {
//...
},
},
// 对于 `Program` 类型的节点(整个程序):
Program: {
// 当我们进入一个节点时:
enter: (path, state) => {
// 从插件选项中获取 `_tracker` 函数的导入路径。
const trackerPath = options.trackerPath;
// 声明一个标志,初始值为 false,表示我们假设程序中没有导入 `_tracker`。
let isHasTracker = false;
// 遍历当前节点(整个程序)的所有子节点。
path.traverse({
// 对于 `ImportDeclaration` 类型的节点(导入声明):
ImportDeclaration(path) {
// 如果当前导入声明的来源与 `_tracker` 函数的导入路径相同:
if (path.node.source.value === trackerPath) {
// 将标志设置为 true,表示我们找到了 `_tracker` 的导入。
isHasTracker = true;
// 停止遍历,因为我们已经找到了 `_tracker` 的导入。
path.stop();
}
},
});
// 如果我们遍历完所有导入声明后都没有找到 `_tracker` 的导入:
if (isHasTracker === false) {
// 使用 `addDefault` 函数向程序中添加 `_tracker` 函数的默认导入。
// `options.trackerPath` 是 `_tracker` 函数的导入路径,
// `{ nameHint: "_tracker" }` 是一个选项对象,用于指定导入的变量名。
addDefault(path, options.trackerPath, { nameHint: "_tracker" });
}
},
},
},
};
};
添加了一个Program的处理函数,在逻辑中,遍历的了整个文件的import语句,并且一一比较了import的source
,如果其中的source.value
有_tracker
,说明文件已经导入了_tracker
一个import语句,如:
import a from 'a.js'
那么可以通过node.source.value
,获取这个AST节点中的a.js
在判断_tracker
的导入路径的时候,代码中是从options.trackerPath
中获取的,而options的配置在插件的引用的地方。并没有hard code
//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [[tracker,{ trackerPath: 'tracker'}]], //update
});
如果没有发现tracker的导入,就需要手动添加了。代码中借用的是addDefault的依赖帮忙添加的。其中{ nameHint: "_tracker" }
用来设置_tracker作为埋点函数的变量名。
我们来跑下代码:
node ./src/index.js
添加成功
看起来大功告成了。我们来捋一下过程:
遍历函数,在函数中添加埋点函数 查找是否有tracker的导入,如果没有,就手动添加
过程很简单,但过于简陋,有几处可以改进的地方:
不仅给箭头函数添加,还可以给函数表达式,函数声明,类方法等函数形式添加埋点 添加tracker的导入,埋点函数变量名_tracker可能会被使用过,所以最好是随机生成埋点函数的变量名 如果文件中已经导入了tracker,我们需要获取用户定义的变量名,并且使用该变量名给函数添加埋点。例如 import _tracker2 from 'tracker';
,这时候调用埋点就要变成_tracker2();
改进
给其他的函数类型添加埋点
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
// 当我们进入一个节点时:
enter: (path, state) => {
// `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。
// 获取箭头函数的函数体的路径。
const bodyPath = path.get("body");
// 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
// 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
const ast = api.template.statement('_tracker()')();
// 将新生成的 `_tracker()` 调用语句插入到箭头函数的函数体的开头。
bodyPath.node.body.unshift(ast);
},
},
},
babel提供这样的功能,字符串拼接的方法来表示遍历多种类型的AST,这样就完成了多种函数类型都可以差入埋点函数了
我们修改测试代码,并且运行看看
import "./index.css";
//##箭头函数
const test1 = () => {};
//函数表达式
const test2 = function () {};
// 函数声明
function test3() {}
// 类方法
class test4 {
test4_0() {}
test4_1 = () => {};
test4_2 = function () {};
}
node ./src/index.js
每个函数都有埋点
不过有一个点,如果箭头函数直接返回结果,现有的代码是不支持的,形如const test_5 = ()=>0
,函数体只是一个statement,而不是一个数组,所以强行执行unshift操作会报错。
需要对代码做些修改
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
// 当我们进入一个节点时:
enter: (path, state) => {
// `path` 是当前节点(箭头函数表达式)的路径对象,它提供了一些操作当前节点的方法。
// 获取箭头函数的函数体的路径。
const bodyPath = path.get("body");
// 使用 Babel 插件 API 的 `template.statement` 方法创建一个新的 AST 节点,
// 这个节点表示 `_tracker()` 这个语句。注意我们需要调用返回的函数(`()`)以生成 AST。
const ast = api.template.statement('_tracker()')();
if (bodyPath.isBlockStatement()) {
bodyPath.node.body.unshift(ast);
} else {
const ast2 = api.template.statement(`{
_tracker();
return BODY;
}`)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast2);
}
}
}
}
在代码中,做了一个对函数节点body属性值类型的判断,如果是isBlockStatement,那就可以执行unshift,如果不是,说明函数单纯返回了一个值,这时候就需要将函数体变成blockStatement,并且函数的返回值依然是原来的值。形如const test_5 = ()=>0
变成const test_5 = ()=>{ return 0; }
。这样就可以添加埋点函数了
运行看看:
搞定
处理埋点函数变量名
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
enter: (path, state) => {
const types = api.types;
const bodyPath = path.get("body");
const ast = state.trackerAst;
if (types.isBlockStatement(bodyPath.node)) {
bodyPath.node.body.unshift(ast);
} else {
const ast2 = api.template.statement(`{
${state.importTrackerId}();
return BODY;
}`)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast2);
}
},
},
Program: {
enter: (path, state) => {
const trackerPath = options.trackerPath;
path.traverse({
ImportDeclaration(path) {
if (path.node.source.value === trackerPath) {
const specifiers = path.get("specifiers.0");
state.importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});
if (!state.importTrackerId) {
state.importTrackerId = addDefault(path, options.trackerPath, {
nameHint: path.scope.generateUid("tracker"),
}).name;
}
state.trackerAst = api.template.statement(`${state.importTrackerId}();`)();
},
},
},
使用了 path.scope.generateUid("tracker")
来生成当前作用域内唯一的变量。借助state,来传递生成的变量,或者是已经定义的变量 在插入埋点函数的时候,就可以读取state中的变量了
总结:
这篇文章较为基础,讲了如何在函数中添加埋点函数,以及如何处理埋点函数的import。
在埋点的时候,需要注意一下几个问题:
函数形态的多样性 埋点函数的变量是否已经定义,如果已经定义,插入埋点的时候,就要使用已经定义的变量名;如果没有定义,插入import的时候,就要保证插全局变量名的唯一性
对每个函数都执行插入埋点操作还是有问题,实际情况并不需要这么做。